2장. RAG 한 줄 직접 만들기
출처: 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(브라이스 유·조경아·박수진·김재웅, 프리렉 2025) | 공식: www.langchain.com
코드는 분위기만 —
embed_query·split_documents·|같은 말은 몰라도 됩니다. 표의 '비유'와 '위험'만 봐도 충분해요.
이 장은 0장에서 그림으로만 봤던 RAG 한 줄을, 이번엔 부품 하나하나 직접 손에 쥐고 만들어 보는 장이다.
문서를 숫자로 바꾸고, 바깥 파일을 읽어 들이고, 길면 자르고, 창고에 넣고, 비슷한 걸 꺼내, 답을 짓는다.
0장 용어와 척추가 머리에 있으면 충분하다. 막히면 0장으로 돌아가면 된다.
0. 이 장의 새 단어 (0장에 없는 것만)
0장 용어집에 없는 말은 딱 3개다.
나머지 어려운 말(임베딩·벡터·청크·검색기·코사인 유사도 등)은 전부 0장에 있다.
메타데이터(metadata)
한 문장 뜻 — 문서 본문 말고, 그 문서에 딸린 꼬리표 정보. 어디서 왔는지(출처)·몇 페이지인지·제목 같은 것.
일상비유 — 택배 상자에 붙은 송장. 상자 안 물건(본문)과 별개로, 보낸 곳·받는 곳·무게가 송장(메타데이터)에 적혀 있다.
한 줄 예 —
# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 본문은 page_content, 꼬리표는 metadata
doc.page_content # "2024년 주택시장 전망은..." ← 알맹이
doc.metadata # {'source': '보고서.pdf', 'page': 8} ← 꼬리표
유사도 검색 / 상위 k개(similarity search / top-k)
한 문장 뜻 — 질문과 가장 비슷한 조각 몇 개만 골라 꺼내 오는 일. 몇 개 꺼낼지를 정하는 숫자가 k다.
일상비유 — 검색창에 단어를 치면 위에서부터 관련 글 3개만 보여 주는 것. 100만 개를 다 보지 않고 위 몇 개만 본다.
한 줄 예 —
# 질문과 비슷한 조각 상위 3개만 꺼내기
# 문서 벡터를 저장하고 비슷한 내용을 찾을 저장소를 준비합니다.
docs = store.similarity_search("환불 규정", k=3)
스트림릿(Streamlit)
한 문장 뜻 — 파이썬 코드만으로 웹 화면(채팅창·버튼)을 만들어 주는 도구. HTML·CSS를 몰라도 된다.
일상비유 — 조립 안 된 가구 대신, 이미 다 만들어진 책상을 사 오는 것. 채팅창·입력칸이 이미 부품으로 있어 가져다 쓰기만 하면 된다.
한 줄 예 —
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
import streamlit as st
st.title("내 챗봇") # 화면에 제목 한 줄
user = st.chat_input("질문하세요") # 채팅 입력칸 하나
(귀납 도입) 이런 적 있죠?
똑똑한 AI에게 "이 PDF 보고서 내용 알려줘"라고 했더니, 그 파일을 본 적도 없으면서 그럴듯하게 둘러댄다.
0장에서 봤다. AI는 자기가 배운 것 밖을 모른다. 우리 PDF는 배운 적이 없다.
그럼 어떻게 해야 AI가 우리 PDF를 보고 답하게 만들까?
순서는 이렇다.
먼저 PDF 글을 컴퓨터가 비교할 수 있게 숫자로 바꾼다.
그다음 PDF 파일을 읽어 들인다.
90,000자나 되는 긴 글은 한 번에 못 넣으니 조각으로 자른다.
조각들을 창고에 넣어 둔다.
질문이 오면 비슷한 조각만 꺼낸다.
그 조각을 AI에게 쥐여 주며 "이거 보고 답해"라고 한다.
# 이 장에서 만들 한 줄 (지금은 분위기만)
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
질문 → 비슷한 조각 검색 → 조각 + 질문을 AI에게 → 답
이 한 줄을, 이제 부품별로 하나씩 만들어 본다.
이 장에서 딱 6가지만
TL;DR
- 임베딩·코사인 유사도 — 글을 숫자로 바꾸고, 방향이 같으면 비슷하다고 잰다.
- 랭체인 임베딩 API — 오픈AI(유료·고품질)와 허깅페이스(무료·로컬) 두 길이 있다.
- 문서 로더 — 웹·PDF·CSV 같은 바깥 파일을 같은 모양으로 읽어 들인다.
- 텍스트 분할 — 길이로 자르거나(빠름), 의미로 자른다(똑똑·비쌈).
- 벡터 DB — 숫자 조각을 창고(Chroma·FAISS)에 넣고 비슷한 걸 빠르게 꺼낸다.
- RAG 챗봇 + 스트림릿 — 부품을 한 줄로 잇고, 화면을 입힌다.
학습 목표
- RAG의 인덱싱 과정과 쿼리 과정을 구분한다.
- 워드·문장·문서 임베딩을 구분하고 코사인 유사도가 무엇인지 설명한다.
- 문서 로더·텍스트 분할기·벡터 DB가 각각 무슨 일을 하는지 설명한다.
- 길이 분할과 의미 분할의 차이를 설명한다.
개념 1 — 임베딩과 코사인 유사도: 글을 숫자로, 비슷함은 방향으로
망가지는 장면
"과일 값이 비싸다"와 "시장 물가가 올랐어요"는 사람 눈엔 비슷하다.
그런데 컴퓨터한테 글자 그대로 비교시키면, 겹치는 글자가 없어 "전혀 다른 글"이라 한다.
의미가 비슷한 글을 영영 못 찾는다.
일상비유
사람마다 키·나이를 숫자 신상카드로 적어 두면, 이름이 달라도 카드 숫자가 비슷한 사람을 찾을 수 있다.
글도 숫자 카드(임베딩)로 바꾸면 의미가 비슷한 글을 찾을 수 있다.
비슷한 정도는 방향으로 잰다. 두 손가락이 같은 곳을 가리키면 비슷한 것이다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 글 → 숫자 신상카드 | embeddings.embed_query("저는 배가 고파요") |
임베딩 모델이 나쁘면 카드가 엉터리 |
| 같은 방향이면 비슷(1) | cos_sim(vec2, vec3) → 1.0 |
1에 가까울수록 비슷 |
| 직각(0)·정반대(-1) | cos_sim(vec1, vec2) → 0.7 |
점수 낮으면 관련 없는 글 |
한 문장 정의 — 임베딩은 글을 숫자 묶음으로 바꾸는 일이고, 코사인 유사도는 두 묶음이 같은 방향을 보는 정도(-1~1)로 비슷함을 재는 방법이다.
예시 폭격
예시 1 (worked — 완성). 임베딩에는 세 종류가 있다. 단위만 다르다.
# 단어 하나를 숫자로 → 워드 임베딩
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
"랭체인" → [-0.01, -0.02, 0.03, 0.12]
# 문장 하나를 숫자로 → 문장 임베딩
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
"안녕하세요" → [-0.01, -0.05, -0.16, 0.19]
# 문서 통째로 숫자로 → 문서 임베딩
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
"랭체인은 ..." → [-0.05, 0.21, 0.04, 0.05]
예시 2 (worked — 완성). 코사인 유사도 점수는 이렇게 읽는다.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
import numpy as np
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from numpy import dot
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from numpy.linalg import norm
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def cos_sim(A, B):
# 계산하거나 처리한 결과를 이 함수를 부른 쪽으로 돌려줍니다.
return dot(A, B) / (norm(A) * norm(B))
# 숫자나 표 데이터를 계산하기 좋은 형태로 준비합니다.
vec2 = np.array([1, 0, 2, 1])
vec3 = np.array([2, 0, 4, 2]) # vec2를 딱 2배 한 것
cos_sim(vec2, vec3) # → 1.0 (크기는 2배지만 방향이 같다 → 매우 비슷)
크기가 2배여도 유사도는 1.0이다.
코사인은 크기를 무시하고 방향만 본다는 증거다.
긴 글과 짧은 글이 길이 차이만으로 "다르다"고 나오는 걸 막아 준다.
예시 3 (부분완성 — 빈칸 채우기). 아래 빈칸에 들어갈 값을 골라 보자.
# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 두 벡터가 정반대 방향이면 코사인 유사도는?
cos_sim([1, 0], [-1, 0]) # → ____
# 보기: 1.0 / 0.0 / -1.0
정답은 -1.0. 정반대 방향이니까.
예시 4 (독립적용 — 미니 시나리오). "과일 값이 비싸다"로 6개 문장 중 비슷한 걸 찾는다.
문장 6개를 모두 숫자 카드로 바꿔 둔다.
질문도 숫자 카드로 바꾼다.
질문 카드와 6개 카드의 코사인 유사도를 각각 잰다.
점수 높은 순으로 3개를 꺼낸다 → '전통 시장', '시장 물가', '주식 시장'.
글자는 안 겹치는데 의미로 찾아냈다.
참고 — 책 2장에서는 점수를 직접 잴 때 코사인 유사도를, 의미 기반 분할(아래 개념 4)에서는 코사인 거리(= 1 − 코사인 유사도)를 쓴다. 유사도는 클수록 비슷, 거리는 작을수록 비슷이다. 같은 걸 뒤집어 본 것뿐이다.
개념 2 — 랭체인 임베딩 API: 유료 길과 무료 길
망가지는 장면
숫자 카드를 만들려면 임베딩 모델이 필요하다.
그런데 모델이 한두 개가 아니다.
어떤 건 돈을 내고 쓰고, 어떤 건 공짜다. 어떤 건 영어를 잘하고, 어떤 건 한국어를 잘한다.
뭘 골라야 할지 막막하다.
일상비유
번역가를 고용하는 것과 같다.
비싼 전문 통역사(오픈AI)는 품질이 좋지만 부를 때마다 돈이 든다.
무료 자원봉사 번역가(허깅페이스 오픈 모델)는 공짜지만 내 컴퓨터에서 직접 돌려야 하고, 분야에 따라 실력 차가 있다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 비싼 전문 통역사 | OpenAIEmbeddings(model="text-embedding-3-small") |
API 호출마다 비용 발생 |
| 무료 자원봉사자 | HuggingFaceEmbeddings(model_name="BAAI/bge-m3") |
복잡한 맥락은 살짝 약함 |
한 문장 정의 — 랭체인 임베딩 API는 글을 숫자로 바꾸는 두 길을 제공한다. 유료지만 고품질인 LLM 제공사 모델과, 무료지만 로컬에서 돌리는 허깅페이스 오픈 모델이다.
예시 폭격
예시 1 (worked — 완성). 유료 길(오픈AI)로 문장 하나를 숫자로 바꾼다.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_openai import OpenAIEmbeddings
# 문장이나 문서를 의미 벡터로 바꾸는 임베딩 단계를 준비합니다.
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 문장이나 문서를 의미 벡터로 바꾸는 임베딩 단계를 준비합니다.
vec = embeddings.embed_query("저는 배가 고파요")
len(vec) # 1536 ← 숫자 1536개짜리 카드
예시 2 (worked — 완성). 무료 길(허깅페이스 bge-m3)은 모델 이름만 바꾸면 끝이다.
# 허깅페이스 연동은 전용 패키지를 씁니다: pip install langchain-huggingface
from langchain_huggingface import HuggingFaceEmbeddings
# 문장이나 문서를 의미 벡터로 바꾸는 임베딩 단계를 준비합니다.
embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-m3")
# 한국어에 강하고, 내 컴퓨터에서 무료로 돈다
부품 하나(모델)만 갈아 끼웠다.
나머지 코드는 그대로다.
이게 0장 척추 7번 "랭체인은 레고"의 실제 모습이다.
예시 3 (독립적용 — 미니 시나리오). 어떤 길을 고를까?
회사 비용을 아껴야 하고 한국어 문서가 많다 → 무료·한국어 강한 bge-m3.
품질이 최우선이고 비용은 괜찮다 → 오픈AI.
한 걸음 더 ▸ (지금 몰라도 됨) — bge-m3는 '급등', '가격' 같은 키워드가 겹치면 잘 찾지만, "시장 물가"처럼 넓은 맥락으로 이어지는 건 살짝 약하다. 모델마다 잘하는 결이 다르다는 것만 알아 두면 된다. 깊은 비교는 더 나중 주제다.
개념 3 — 문서 로더: 바깥 파일을 같은 모양으로 읽기
망가지는 장면
내 자료는 PDF, 동료 자료는 웹페이지, 엑셀에서 받은 건 CSV다.
이걸 다 다른 방식으로 읽으면, 읽는 코드를 파일 종류마다 새로 짜야 한다.
뒤에 자르고·숫자로 바꾸는 코드도 종류마다 다 달라진다. 끔찍하다.
일상비유
나라마다 다른 콘센트를 한 모양으로 맞춰 주는 멀티 어댑터.
파일이 PDF든 웹이든 CSV든, 어댑터(문서 로더)를 통과하면 전부 같은 모양(Document)으로 나온다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 웹페이지용 어댑터 | WebBaseLoader("https://...") |
사이트 막으면 못 읽음 |
| PDF용 어댑터 | PyPDFLoader("보고서.pdf") |
이미지·빈 페이지는 빠질 수 있음 |
| CSV용 어댑터 | CSVLoader("거래.csv") |
빈 행도 한 조각으로 셈 |
한 문장 정의 — 문서 로더는 웹·PDF·CSV 등 80종 넘는 바깥 파일을, 본문(page_content)과 꼬리표(metadata)를 가진 같은 모양의 문서로 바꿔 주는 어댑터다.
예시 폭격
예시 1 (worked — 완성). PDF를 읽으면 본문과 꼬리표가 같이 나온다.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_community.document_loaders import PyPDFLoader
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
loader = PyPDFLoader("KB부동산보고서.pdf")
pages = loader.load() # 페이지 단위 Document 목록으로 읽는다 (더 잘게 쪼개기는 다음 절 분할기 담당)
# 결과 한 조각:
# Document(metadata={'source': '...보고서.pdf', 'page': 8},
# page_content='2024년 주택시장 전망...')
예시 2 (worked — 완성). 같은 PDF라도 어댑터 종류에 따라 속도와 꼬리표 양이 다르다.
| 로더 | 속도 (책 실측) | 꼬리표(메타데이터) | 언제 쓰나 |
|---|---|---|---|
PyPDFLoader |
보통 (약 24초) | 기본(출처·페이지) | 간단한 표준 PDF |
PyMuPDFLoader |
빠름 (약 2.6초) | 풍부(작성자·포맷·전체쪽수) | 대용량·여러 파일 |
PDFPlumberLoader |
느림 (약 39초) | 풍부(표를 HTML로) | 표·구조가 중요할 때 |
세 로더 모두 같은 PDF를 83개 조각으로 읽었다.
다만 빠른 건 2.6초, 느린 건 39초였다.
급할 땐 빠른 로더, 표가 중요하면 느려도 표 잘 뽑는 로더를 고른다.
예시 3 (부분완성 — 빈칸 채우기). 상황에 맞는 로더를 골라 보자.
# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 200쪽 PDF 100개를 빨리 읽어야 한다 → ____Loader
# 표가 빼곡한 PDF의 표를 살려야 한다 → ____Loader
# 보기: PyPDF / PyMuPDF / PDFPlumber
정답: 빠른 건 PyMuPDF, 표는 PDFPlumber.
예시 4 (독립적용 — 미니 시나리오). 웹페이지 여러 개를 한 번에 읽기.
읽고 싶은 주소를 리스트로 넘긴다.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_community.document_loaders import WebBaseLoader
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
loader = WebBaseLoader([
# 프롬프트·모델·출력 처리를 하나의 실행 흐름으로 이어 붙입니다.
"https://python.langchain.com/docs/introduction/",
# 프롬프트·모델·출력 처리를 하나의 실행 흐름으로 이어 붙입니다.
"https://langchain-ai.github.io/langgraph/",
])
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
docs = loader.load()
docs[0].metadata # {'source': '...', 'title': '...', 'language': 'en'}
PDF든 웹이든, 결과 모양(Document)이 똑같다.
그래서 다음 단계(자르기·숫자로 바꾸기)를 파일 종류와 상관없이 그대로 쓸 수 있다.
개념 4 — 텍스트 분할: 길이로 자르기 vs 의미로 자르기
망가지는 장면
KB 부동산 보고서는 90,699자다.
이걸 통째로 AI에 넣으면? 너무 길어서 못 받는다.
설령 받아도, "환불 규정"을 물었는데 책 전체가 딸려 와 정작 필요한 한 단락을 못 짚는다.
그래서 미리 조각(청크)으로 잘라 둬야 한다.
일상비유
두꺼운 책을 페이지별로 찢어 분류함에 꽂는 것.
자르는 방법은 두 가지다.
하나는 자(尺)로 길이 재서 똑같이 자르기(빠름, 단순).
하나는 내용을 읽고 주제 바뀌는 데서 자르기(똑똑, 느림·비쌈).
| 비유 | 코드 | 위험 |
|---|---|---|
| 자로 길이 재서 자르기 | RecursiveCharacterTextSplitter(chunk_size=500) |
문장 중간에서 뚝 끊길 수 있음 |
| 자른 조각끼리 살짝 겹치기 | chunk_overlap=50 |
0이면 경계에서 맥락 끊김 |
| 주제 바뀌는 데서 자르기 | SemanticChunker(OpenAIEmbeddings()) |
똑똑하지만 비용 듦 |
한 문장 정의 — 텍스트 분할은 긴 문서를 검색하기 좋은 조각으로 자르는 일이다. 길이로 자르는 RecursiveCharacterTextSplitter는 빠르고, 의미로 자르는 SemanticChunker는 조각마다 한 주제가 모이게 한다.
예시 폭격
예시 1 (worked — 완성). 길이로 자르기. 500자 넘지 않게, 50자씩 겹쳐서.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 긴 문서를 검색하기 좋은 작은 조각으로 나눕니다.
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
texts = splitter.split_documents(pages)
len(texts) # 242 ← 84쪽 PDF가 242조각으로
chunk_size는 한 조각 최대 글자 수.
chunk_overlap은 옆 조각과 겹치는 글자 수.
겹침을 두면 1번 조각 끝 50자가 2번 조각 앞에도 들어가, 경계에서 말이 잘리는 걸 줄인다.
예시 2 (worked — 완성). 길이 자르기의 순서는 큰 단위부터 작은 단위로.
# 기본 구분자 순서: 단락 → 줄 → 공백 → 글자
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
["\n\n", "\n", " ", ""]
# 먼저 단락(\n\n)으로 자르고, 그래도 길면 줄(\n)로, 또 길면 공백으로...
큰 덩어리부터 자르니 "재귀적(recursive)"이라 부른다.
예시 3 (worked — 완성). 의미로 자르기. 같은 PDF, 방식만 바꾸면 조각 수가 달라진다.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_experimental.text_splitter import SemanticChunker
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_openai import OpenAIEmbeddings
# 기본(백분위수 95): 의미 차이 상위 5% 지점에서만 자름
# 긴 문서를 검색하기 좋은 작은 조각으로 나눕니다.
splitter = SemanticChunker(OpenAIEmbeddings())
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
chunks = splitter.split_documents(pages)
len(chunks) # 165
| 방식 | 설정 이름 | 성격 | 조각 수(책 실측) |
|---|---|---|---|
| 백분위수(기본) | "percentile" |
의미가 극단적으로 바뀌는 데서만 | 165 |
| 표준편차 | "standard_deviation" |
평균보다 훨씬 벗어난 데서 (덜 자름) | 84 |
| 사분위수 | "interquartile" |
가운데 50% 기준, 튀는 값에 덜 민감 | 145 |
세 방식 모두 코사인 거리로 "문장끼리 얼마나 다른가"를 재고, 많이 다른 데서 자른다.
기준값을 낮추면 조각이 많아지고(짧아짐), 높이면 적어진다(길어짐).
예시 4 (부분완성 — 빈칸). 둘 중 뭘 고를까?
# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 일단 빨리 돌려 보고 싶다 (비용 0) → ____TextSplitter
# 검색 정확도가 핵심이고 비용은 괜찮다 → ____Chunker
# 보기: RecursiveCharacter / Semantic
정답: 빠른 건 RecursiveCharacter, 정확한 건 Semantic.
예시 5 (독립적용 — 미니 시나리오). 90,699자 보고서를 1,000자 조각으로.
chunk_size=1000, chunk_overlap=200으로 자른다.
84쪽이 138조각이 된다.
가장 긴 조각 966자, 가장 짧은 56자, 평균 664자.
모든 조각이 1,000자를 넘지 않는 걸 확인할 수 있다.
한 걸음 더 ▸ (지금 몰라도 됨) — 길이로 자르면 문장이 뚝 끊긴 불완전한 조각이 생겨, RAG 챗봇 답 품질이 떨어질 수 있다. 그래서 의미 기반 분할이 나왔다. 단, 의미 분할은 문장마다 임베딩 API를 부르니 돈이 더 든다. 어느 쪽이 나은지는 문서와 상황에 따라 다르다. 지금은 "두 가지 방법이 있다"만 알면 충분하다.
개념 5 — 벡터 DB: 숫자 조각 창고와 빠른 꺼내기
망가지는 장면
앞에서는 판다스(엑셀 비슷한 표)에 숫자 조각을 담고, 직접 코사인 유사도를 다 계산했다.
조각이 6개일 땐 괜찮다.
그런데 100만 개면? 질문 하나에 100만 번 계산해야 한다. 너무 느리다.
일상비유
신상카드를 잔뜩 모았으면, 그냥 상자에 쏟아 두지 말고 잘 정리된 캐비닛에 넣어 둔다.
"나랑 비슷한 사람" 카드를 순식간에 뽑아 주는 캐비닛이 벡터 DB다.
대표 캐비닛이 둘. 가벼운 Chroma, 빠른 FAISS.
| 비유 | 코드 | 위험 |
|---|---|---|
| 가벼운 캐비닛(프로토타입) | Chroma.from_documents(splits, embedding) |
큰 규모엔 한계 |
| 빠른 대형 캐비닛(대규모) | FAISS.from_documents(splits, embedding) |
설정·분산은 까다로움 |
| 캐비닛에서 비슷한 카드 꺼내기 | store.similarity_search("질문", k=3) |
k 안 정하면 기본 4개 |
한 문장 정의 — 벡터 DB는 숫자 조각(임베딩)을 잔뜩 저장하고 "비슷한 것 찾아줘"를 빠르게 해 주는 창고다. 가벼운 Chroma와 빠른 FAISS가 대표이며, 둘 다 as_retriever()로 검색기로 바꿔 쓴다.
예시 폭격
예시 1 (worked — 완성). Chroma에 넣고, 비슷한 조각 꺼내기.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_chroma import Chroma
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_openai import OpenAIEmbeddings
# 138개 조각을 숫자로 바꿔 창고에 저장
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
vectordb = Chroma.from_documents(
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
documents=splits,
# 문장이나 문서를 의미 벡터로 바꾸는 임베딩 단계를 준비합니다.
embedding=OpenAIEmbeddings(),
persist_directory="./chroma_db", # 이 경로에 파일로 영구 저장
)
# "수도권 주택 매매 전망"과 비슷한 조각 3개 꺼내기
# 문서 벡터를 저장하고 비슷한 내용을 찾을 저장소를 준비합니다.
docs = vectordb.similarity_search("수도권 주택 매매 전망", k=3)
persist_directory를 적으면 디스크(chroma.sqlite3)에 남아, 다음에 또 쓴다.
안 적으면 메모리에만 있다가 끄면 사라진다.
예시 2 (worked — 완성). FAISS는 거의 같지만 저장·확인 방법이 다르다.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_community.vectorstores import FAISS
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
faiss_db = FAISS.from_documents(documents=splits, embedding=OpenAIEmbeddings())
faiss_db.index.ntotal # 138 ← Chroma는 _collection.count()
faiss_db.save_local("./faiss_index") # Chroma는 persist_directory로 자동
같은 일(저장·확인)을 하는데 메서드 이름만 다르다.
Chroma는 persist_directory·_collection.count(), FAISS는 save_local()·index.ntotal.
예시 3 (부분완성 — 빈칸). 캐비닛을 골라 보자.
# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 혼자 빠르게 실험·프로토타입 → ______
# 수백만 벡터를 GPU로 초고속 검색해야 함 → ______
# 보기: Chroma / FAISS
정답: 가벼운 실험은 Chroma, 대규모 초고속은 FAISS(메타 개발, GPU 가속).
예시 4 (독립적용 — 미니 시나리오). 창고를 검색기로 바꾸기.
창고는 그냥 두면 "저장소"다.
as_retriever()를 부르면 질문 받아 꺼내 주는 "검색기"가 된다.
# 문서 벡터를 저장하고 비슷한 내용을 찾을 저장소를 준비합니다.
retriever = vectordb.as_retriever(search_kwargs={"k": 3})
# 이제 retriever.invoke("질문") 하면 비슷한 조각 3개를 돌려줌
중요한 점 하나.
as_retriever()는 Chroma든 FAISS든 똑같이 쓴다.
한번 검색기로 만들면, 창고 종류와 상관없이 다음 단계(챗봇)에 꽂을 수 있다.
참고 — FAISS를 파일에서 다시 불러올 땐
allow_dangerous_deserialization=True를 켠다. 파이썬이 "이 파일 위험할 수 있다"고 막는 걸 푸는 옵션이다. 내가 방금 저장한 파일일 때만 켠다.
개념 6 — RAG 챗봇과 스트림릿: 부품을 한 줄로 잇고 화면 입히기
망가지는 장면
부품은 다 만들었다. 로더, 분할기, 임베딩, 창고, 검색기.
그런데 따로 놀면 챗봇이 아니다.
질문이 오면 → 검색기로 조각 찾고 → 조각을 프롬프트에 넣고 → AI에게 보내 답받는 한 줄로 이어야 한다.
게다가 까만 터미널 화면은 친구에게 보여 주기 민망하다.
일상비유
컨베이어 벨트(체인). 질문을 올리면 검색→프롬프트→모델→정리를 차례로 거쳐 답이 나온다.
그리고 그 위에 예쁜 채팅창(스트림릿)을 씌운다. 손님은 벨트 속을 몰라도 채팅만 하면 된다.
| 비유 | 코드 | 위험 |
|---|---|---|
| 부품을 벨트로 잇기 | chain = 검색 \| prompt \| model \| parser |
부품 모양 안 맞으면 안 이어짐 |
| 이전 대화 기억하기 | RunnableWithMessageHistory(...) |
없으면 방금 말도 까먹음 |
| 채팅 화면 입히기 | st.chat_input("질문하세요") |
없으면 까만 터미널뿐 |
| 무거운 준비는 한 번만 | @st.cache_resource |
없으면 질문마다 PDF 재로드 |
한 문장 정의 — RAG 챗봇은 검색기·프롬프트·모델을 한 줄(체인)로 잇고 대화 기억을 붙인 것이며, 스트림릿은 그 위에 파이썬만으로 채팅 화면을 입히는 도구다.
예시 폭격
예시 1 (worked — 완성). 부품을 한 줄로 잇기. 0장 척추 6번 "RAG는 한 줄"의 실제 코드다.
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def format_docs(docs):
return "\n\n".join(d.page_content for d in docs) # 조각들을 한 덩어리로
# 프롬프트·모델·출력 처리를 하나의 실행 흐름으로 이어 붙입니다.
chain = (
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
RunnablePassthrough.assign(
context=lambda x: format_docs(retriever.invoke(x["question"])) # ① 검색
)
| prompt # ② 질문+조각을 지시문에 끼움
| model # ③ AI에게 보냄
| StrOutputParser() # ④ 답을 깔끔한 글자로
)
|(파이프) 하나하나가 "그다음으로 넘긴다"는 뜻이다.
검색 → 프롬프트 → 모델 → 정리. 0장에서 본 그 한 줄 그대로다.
예시 2 (worked — 완성). 대화 기억 붙이기. 이게 있어야 "방금 뭐라 했지?"에 답한다.
(RunnableWithMessageHistory는 아직 동작하지만 은퇴 예고 상태 — 현행 공식 권장은 6장의 LangGraph 체크포인터다. 여기서는 흐름 이해용으로 쓴다.)
# `chat_history`에 중간 결과를 담아 다음 줄에서 재사용합니다.
chat_history = ChatMessageHistory()
# 프롬프트·모델·출력 처리를 하나의 실행 흐름으로 이어 붙입니다.
chain_with_memory = RunnableWithMessageHistory(
# 프롬프트·모델·출력 처리를 하나의 실행 흐름으로 이어 붙입니다.
chain,
# 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
lambda session_id: chat_history,
# 모델에게 어떤 역할과 입력을 줄지 프롬프트 틀을 만듭니다.
input_messages_key="question",
# 모델에게 어떤 역할과 입력을 줄지 프롬프트 틀을 만듭니다.
history_messages_key="chat_history",
)
이 기억 덕분에, 사용자가 "방금 제가 뭐라고 했죠?" 물으면 챗봇이 직전 말을 그대로 짚어 준다.
예시 3 (worked — 완성). 화면 입히기. 채팅창 한 줄이면 된다.
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
import streamlit as st
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
st.title("KB 부동산 보고서 챗봇")
# 조건을 확인해서 상황에 맞는 처리 경로를 고릅니다.
if user_input := st.chat_input("부동산 질문을 입력하세요"):
# 파일·연결 같은 자원을 열고, 끝나면 자동으로 정리되게 묶습니다.
with st.spinner("답변 생성 중..."):
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
response = chain.invoke({"question": user_input}, ...)
# 준비한 함수나 객체를 호출해 예제의 핵심 동작을 실행합니다.
st.markdown(response)
HTML·CSS는 한 줄도 없다.
파이썬만으로 채팅창·로딩 표시·답 출력이 다 된다.
예시 4 (부분완성 — 빈칸). 스트림릿 챗봇이 질문할 때마다 PDF를 다시 읽어 너무 느리다. 무엇을 빠뜨렸나?
# 무거운 준비(PDF 로드·창고 만들기) 함수 위에 빠진 한 줄:
# 아래 함수나 클래스를 프레임워크가 특별한 용도로 인식하게 표시합니다.
@____________
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def build_chain():
...
# 보기: st.cache_resource
정답: @st.cache_resource. 이게 있어야 무거운 준비를 딱 한 번만 하고 결과를 재사용한다.
예시 5 (독립적용 — 미니 시나리오). 완성된 챗봇과의 실제 대화.
사용자: "랭체인 챗봇 만들기 실습 중입니다."
사용자: "방금 제가 뭐라고 했죠?" → 챗봇이 직전 말을 정확히 짚어 준다(기억 덕분).
사용자: "노후계획도시가 어디야?" → 보고서 조각을 찾아 "분당·일산·중동·평촌·산본"이라 답한다(검색 덕분).
기억 + 검색이 합쳐져, 우리 PDF 기반의 똑똑한 챗봇이 완성됐다.
전체 한 줄 다시 보기
이 장에서 만든 부품을 순서대로 이으면 RAG 한 줄이다.
[인덱싱 — 미리 준비]
문서 → 문서 로더 → 텍스트 분할 → 임베딩 → 벡터 DB 저장
[쿼리 — 질문 올 때마다]
질문 → 검색기(비슷한 조각 k개) → 프롬프트 → 모델 → 답
인덱싱은 도서관에 책을 미리 분류해 꽂아 두는 일.
쿼리는 손님이 물을 때 찾아다 주는 일.
0장 척추 6번에서 그림으로 봤던 그 한 줄을, 이번엔 직접 손으로 만들었다.
단순 규칙 (헷갈리면 이대로)
- 임베딩 모델: 일단 오픈AI(고품질)로 시작, 비용·한국어 중요하면 bge-m3(무료).
- 로더: 보통 PyPDFLoader, 빠른 게 필요하면 PyMuPDFLoader, 표가 중요하면 PDFPlumberLoader.
- 분할:
chunk_size=1000, chunk_overlap=200으로 시작, 정확도 보며 조정. - 창고: 실험은 Chroma, 수백만 규모는 FAISS.
- 검색 개수:
k=3명시(안 하면 기본 4개라 토큰 낭비). - 스트림릿: 무거운 준비 함수엔
@st.cache_resource꼭.
더 해보기
검증된 공식 실습·문서 (생존 확인 2026-05-21).
- 책 공식 실습 노트북 — github.com/langchain-kr/langchain-tutorial (2장 폴더: Document Loader·Embedding·Vector DB·RAG Chatbot)
- 코랩 바로 열기 (문서 로더) — ch02 Document Loader
- 랭체인 RAG 개념 — concepts/rag
- 임베딩 개념 — concepts/embedding_models
- 텍스트 분할 개념 — concepts/text_splitters
- 벡터 저장소 개념 — concepts/vectorstores
- 문서 로더 전체 목록 — integrations/document_loaders
- OpenAI 임베딩 가이드 — platform.openai.com 임베딩
- 스트림릿 시작하기 — docs.streamlit.io
최신 동향 (검증 2026-07-09) — 책은 2025년(랭체인 0.3) 기준이라, 그 뒤 바뀐 것만 짧게. ① 책의
text-embedding-ada-002대신text-embedding-3-small/-large가 현행 오픈AI 임베딩이다(차원 축소 옵션으로 저장 비용 절감 가능) — 공식 목록. ② 2025-10 랭체인 1.0이 나오면서 옛 부품 상당수가langchain-classic패키지로 분리됐고, 허깅페이스 연동은langchain-huggingface전용 패키지가 표준이 됐다 — 마이그레이션 가이드. 본문 코드는 전부 현행 기준으로 갱신돼 있다.
연습문제
- 설명.
RAG 한 줄 직접 만들기의 핵심을 처음 듣는 사람에게 한 문장으로 설명하라. - 구분. 두 개념(
검색,생성)을 실제 예시 하나로 구분하라. - 적용. 내 프로젝트나 학습 노트에서 이 장의 개념을 적용해 작게 개선할 지점을 하나 고르라.
부록 A. 쉬운 용어 사전
| 용어 | 아주 쉬운 뜻 | 이 장에서 나온 위치 |
|---|---|---|
| 검색 | 질문에 답하기 전에 관련 자료나 근거를 찾아오는 일. | 부록 B와 본문 예시 |
| 생성 | 찾은 근거와 요청을 바탕으로 새 답변 문장을 만드는 일. | 부록 B와 본문 예시 |
| 문서 로더 | PDF, 웹, 파일 같은 자료를 프로그램이 읽을 수 있는 문서 형태로 바꾸는 부품. | 부록 B와 본문 예시 |
| 텍스트 분할 | 긴 문서를 검색하기 좋게 작은 조각으로 나누는 작업. | 부록 B와 본문 예시 |
부록 B. 헷갈리는 개념 비교표
| A | B | 구분 포인트 |
|---|---|---|
| 검색 | 생성 | 검색은 근거를 찾고, 생성은 그 근거를 바탕으로 답을 만든다. |
| 문서 로더 | 텍스트 분할 | 로더는 읽어 오고, 분할은 읽어 온 것을 작은 조각으로 나눈다. |
부록 C. 더 읽을 자료
- 이 장의
더 해보기섹션 — 이미 모아 둔 공식 문서나 실습 링크가 있으면 여기서 먼저 확인한다. - 같은 책의
0장 한눈에 보기— 용어가 막히면 0장의 용어집과 개념 척추로 돌아간다. - 원본 딥다이브판 같은 장 — 입문판을 읽고 큰 흐름이 잡힌 뒤 세부 논리를 더 깊게 확인한다.
- 이 장의
flashcards.json— 읽은 직후 질문만 보고 답을 떠올리는 회상 연습에 쓴다.
부록 D. 연습문제 풀이
- 설명 예시.
RAG 한 줄 직접 만들기는 RAG에서 자료를 더 잘 찾고, 근거를 더 안전하게 붙이고, 답변 흐름을 더 다루기 쉽게 만드는 방법을 보는 장이다. 중요한 것은 용어를 외우는 것이 아니라, 이 개념이 어떤 입력·부품·결정에 영향을 주는지 말로 풀어 보는 것이다. - 구분 예시. 두 개념(
검색,생성)의 차이는 이렇게 잡으면 된다. 검색은 근거를 찾고, 생성은 그 근거를 바탕으로 답을 만든다. 실제 사례를 볼 때는 목적, 입력, 실패했을 때의 증상을 따로 적어 보면 헷갈리지 않는다. - 적용 예시. 가장 작은 개선부터 고른다. 예를 들어 이름을 더 분명히 하거나, 평가 기준을 한 줄 추가하거나, 직접 알 필요 없는 내부 정보를 감추는 식으로 시작한다. 한 번에 크게 갈아엎는 것보다 작은 변경 하나를 확인하며 진행하는 쪽이 입문 단계에 맞다.
다음 장 예고
다음 장에서는 글뿐 아니라 이미지·표까지 함께 다루는 RAG로 넓혀 간다.
이 장의 한 줄(로드→분할→임베딩→저장→검색→생성)이 머리에 있으면 충분하다. 나머지는 다음 장에서 천천히 얹는다.
클릭하거나 Space를 눌러 뒤집기